6장. 레고 조립도로 만드는 똑똑한 RAG
출처: 『RAG 마스터: 랭체인으로 완성하는 LLM 서비스』(브라이스 유·조경아·박수진·김재웅 지음, 프리렉 2025) | 공식: langchain-ai.github.io/langgraph
코드는 분위기만 —
add_node·compile·stream같은 말은 몰라도 됩니다. 표의 '비유'와 '위험'만 봐도 충분해요.
지금까지 RAG는 한 줄로 쭉 흐르는 컨베이어 벨트였다.
문서를 찾고 → 보태고 → 답한다. 위에서 아래로 한 번만 흐른다.
그런데 현실은 그렇게 단순하지 않다.
"찾아온 문서가 엉뚱하면 다시 찾아라."
"답으로 만든 코드가 안 돌아가면 다시 만들어라."
이렇게 되돌아가거나 갈림길에서 고르는 일이 자꾸 생긴다.
컨베이어 벨트는 한 방향뿐이라 이걸 못 한다.
이 장은 되돌아가고 갈라지는 RAG를 만드는 도구, 랭그래프(LangGraph) 이야기다.
0. 이 장의 새 단어 (0장에 없는 것만 3개)
이 셋만 알면 6장은 끝까지 읽힌다.
나머지 어려운 말(임베딩·검색기·벡터DB 등)은 전부 0장에 있다. 막히면 0장으로.
노드(node)와 에지(edge)
한 문장 뜻 — 일을 하는 작업칸이 노드, 작업칸끼리 잇는 화살표가 에지.
일상비유 — 보드게임 판. 칸(노드)에 도착하면 적힌 일을 하고, 화살표(에지)를 따라 다음 칸으로 간다.
한 줄 예 —
# 아래 예제는 핵심 흐름만 짧게 보여 줍니다.
graph.add_node("검색칸", 검색함수) # 작업칸 하나 놓기
graph.add_edge("검색칸", "답하기칸") # 두 칸을 화살표로 잇기
상태(state)
한 문장 뜻 — 작업칸들이 돌려 쓰는 공용 메모지. 누가 적어도 다음 칸이 그걸 본다.
일상비유 — 릴레이 바통에 매단 메모. 주자가 바뀌어도 메모는 그대로 넘어간다. 다음 주자가 거기에 한 줄 더 적는다.
한 줄 예 —
# 공용 메모에 '질문'과 '찾은 문서' 칸을 미리 만들어 둠
# 관련 데이터와 동작을 하나의 이름 아래 묶는 클래스입니다.
class State(TypedDict):
# 이 줄은 예제 흐름을 이루는 실제 처리 단계입니다.
question: str
# 검색이나 요약에 쓸 원본 문서를 읽어 옵니다.
documents: list
리듀서(reducer)
한 문장 뜻 — 메모를 지우고 새로 쓸지, 밑에 이어 쓸지 정하는 규칙.
일상비유 — 화이트보드냐 노트냐. 화이트보드는 새로 쓰면 앞 글자가 지워진다. 노트는 밑에 줄을 더한다. 리듀서는 "이 칸은 노트처럼 이어 써"라고 정하는 표시다.
한 줄 예 —
# 이 메모칸은 '이어 쓰기'(노트)로 — 새 메시지가 아래에 쌓임
# 모델에게 어떤 역할과 입력을 줄지 프롬프트 틀을 만듭니다.
messages: Annotated[list, add_messages]
이 장에서 딱 5가지만
- 한 줄로만 흐르던 RAG를 판(그래프)으로 바꾸면, 되돌아가고 갈라질 수 있다.
- 판은 셋으로 만든다 — 일하는 칸(노드), 잇는 화살표(에지), 돌려 쓰는 메모(상태).
- 갈림길은 조건부 에지가 만든다. 메모를 보고 "이쪽? 저쪽?"을 고른다.
- 메모지(상태)를 저장해 두면, 챗봇이 이전 대화를 기억한다. 중간에 사람이 끼어들 수도 있다.
- 이 셋으로 스스로 고치는 RAG와 스스로 테스트하는 코드 챗봇을 조립한다.
지금은 "한 줄 → 판으로 바뀐다" 한 가지만 머리에 넣어도 된다.
개념 1 — 왜 판(그래프)인가
망가지는 장면
컨베이어 벨트로 RAG를 만들었다.
문서 검색 → 답 생성, 딱 한 방향.
그런데 검색이 엉뚱한 문서를 물어왔다.
벨트는 한 방향뿐이라 되돌아가 다시 검색할 길이 없다.
엉뚱한 문서를 그대로 안고 틀린 답을 뱉는다.
일상비유
보드게임 판을 떠올려 보자.
칸마다 할 일이 적혀 있고, 화살표로 이어진다.
직선 길만 있는 게 아니다.
"꽝이면 출발칸으로 돌아가시오"(되돌아가기), "동전 앞면이면 왼쪽, 뒷면이면 오른쪽"(갈림길) 같은 칸이 있다.
이게 판이다. 한 줄 벨트가 못 하는 되돌아가기·갈림길을 판은 한다.
| 비유 | 코드 | 위험 |
|---|---|---|
| 한 방향 컨베이어 벨트 | chain = prompt \| model |
틀려도 되돌아가 못 고침 |
| 되돌아가는 보드게임 판 | workflow.add_node("검색칸", ...) |
화살표 안 그으면 칸이 떠 있음 |
한 문장 정의 — 랭그래프는 RAG를 직선 벨트가 아니라 판(그래프)으로 짜서, 되돌아가기와 갈림길을 자유롭게 만들 수 있게 해 준다.
예시 폭격
예시 1 (완성예) — 직선 벨트와 판을 나란히 보자.
# 직선 벨트: 한 번 흐르면 끝, 되돌아갈 수 없음
# 답변을 생성할 LLM 클라이언트나 모델 설정을 준비합니다.
answer = retriever_then_llm(question)
# 판: 칸과 화살표로 짜서 되돌아가기·갈림길 허용
# 준비한 함수나 객체를 호출해 예제의 핵심 동작을 실행합니다.
workflow.add_node("검색칸", 검색)
# 준비한 함수나 객체를 호출해 예제의 핵심 동작을 실행합니다.
workflow.add_node("답하기칸", 답하기)
workflow.add_edge("검색칸", "답하기칸") # 검색 끝나면 답하기로
예시 2 (부분완성) — 빈칸을 채워 칸 하나를 더 놓아 보자.
# 준비한 함수나 객체를 호출해 예제의 핵심 동작을 실행합니다.
workflow.add_node("검색칸", 검색)
workflow.add_node("____", 평가) # 빈칸: "평가칸" — 찾은 문서가 쓸 만한지 보는 칸
예시 3 (독립적용) — "번역칸"을 놓고 "검색칸" 뒤에 잇는 두 줄을, 위 예시만 보고 직접 떠올려 보라. (add_node로 칸을 놓고 add_edge로 화살표를 긋는다.)
미니 시나리오
"검색이 틀리면 다시 검색"이 필요한가? → 직선 벨트는 못 한다. 판으로 가야 한다.
그냥 "문서 찾아 한 번에 답"이면? → 굳이 판까지 안 가도 된다. 벨트로 충분.
개념 2 — 칸·화살표·메모 (판의 세 부품)
망가지는 장면
판을 만들겠다고 마음먹었다.
그런데 막상 뭘 어떻게 놓는지 모른다.
칸은 어떻게 만들고, 칸끼리는 뭘로 잇고, 칸 사이에 정보는 어떻게 전달하지?
부품 이름을 모르면 판을 못 짠다.
일상비유
요리 방송 세트를 떠올려 보자.
작업대가 있다(칸). 손질대 → 조리대 → 플레이팅대.
작업대 사이에 이동 동선이 있다(화살표). 손질 끝나면 조리대로 간다.
그리고 공용 도마가 작업대 사이를 옮겨 다닌다(메모). 손질대에서 썬 재료가 도마째 조리대로 넘어간다.
판의 부품도 똑같이 셋이다.
| 비유 | 코드 | 위험 |
|---|---|---|
| 작업대(칸) | add_node("조리대", 조리함수) |
동선 안 이으면 일을 못 받음 |
| 이동 동선(화살표) | add_edge("손질대", "조리대") |
화살표 거꾸로면 순서 꼬임 |
| 공용 도마(메모) | class State(TypedDict): ... |
칸 이름 오타 나면 메모 못 읽음 |
한 문장 정의 — 랭그래프 판은 일하는 칸(노드), 잇는 화살표(에지), 돌려 쓰는 메모(상태) 세 부품으로 만든다.
칸 = 그냥 파이썬 함수
겁먹을 것 없다. 칸은 함수 하나다.
메모(state)를 받아서, 일을 하고, 메모에 적을 내용을 돌려준다.
# 칸 하나 = 함수 하나. 메모(state)를 받아 메모에 적을 것을 돌려줌
# 반복해서 쓸 처리 흐름에 이름을 붙인 함수입니다.
def my_node(state):
return {"messages": ["안녕하세요!"]} # 이 한 줄을 메모에 적어 줘
builder.add_node("인사칸", my_node) # 함수를 칸으로 등록
시작칸(START)과 끝칸(END)
판에는 출발 지점과 도착 지점이 있다.
START는 "여기서 시작"이고, END는 "여기서 끝".
# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langgraph.graph import START, END
graph.add_edge(START, "인사칸") # 출발 → 인사칸부터
graph.add_edge("인사칸", END) # 인사칸 끝나면 종료
예시 폭격
예시 1 (완성예) — 칸 하나짜리 가장 작은 판.
# 반복해서 쓸 처리 흐름에 이름을 붙인 함수입니다.
def chatbot(state):
# 계산하거나 처리한 결과를 이 함수를 부른 쪽으로 돌려줍니다.
return {"messages": ["네, 도와드릴게요."]}
# 준비한 함수나 객체를 호출해 예제의 핵심 동작을 실행합니다.
builder.add_node("챗봇칸", chatbot)
builder.add_edge(START, "챗봇칸") # 시작 → 챗봇칸
builder.add_edge("챗봇칸", END) # 챗봇칸 → 끝
예시 2 (부분완성) — 빈칸을 채워 출발 화살표를 그어 보자.
# 준비한 함수나 객체를 호출해 예제의 핵심 동작을 실행합니다.
builder.add_node("검색칸", 검색)
builder.add_edge(____, "검색칸") # 빈칸: START — 검색칸부터 시작
예시 3 (독립적용) — "검색칸 → 답하기칸 → 끝" 세 줄을 직접 써 보라. (화살표 세 개: START→검색칸, 검색칸→답하기칸, 답하기칸→END.)
미니 시나리오
칸이 메모에 안 적고 그냥 print만 하면? → 다음 칸은 그 결과를 못 본다. 칸은 돌려줘야 메모에 남는다.
START 화살표를 안 그으면? → 어디서 시작할지 몰라 판이 안 돈다.
개념 3 — 메모를 어떻게 쓸까 (덮어쓰기 vs 이어쓰기)
망가지는 장면
챗봇을 만들었다.
대화가 오갈 때마다 메모의 "메시지" 칸을 새로 쓴다.
그랬더니 새 메시지가 들어올 때마다 앞 대화가 지워진다.
"안녕"이라 했던 게 사라지고, 방금 한 말만 남는다.
챗봇이 바로 앞 문장조차 기억 못 한다.
일상비유
화이트보드와 노트의 차이다.
화이트보드는 새로 쓰면 앞 글자가 지워진다.
노트는 새 줄이 밑에 쌓인다.
대화 메시지는 노트처럼 쌓여야 한다. 그래야 흐름이 보인다.
이 "노트처럼 이어 써"를 정하는 게 리듀서다.
| 비유 | 코드 | 위험 |
|---|---|---|
| 화이트보드(덮어쓰기) | count: int |
새 값이 옛 값을 지움 |
| 노트(이어쓰기) | Annotated[list, add_messages] |
안 붙이면 대화가 사라짐 |
한 문장 정의 — 리듀서는 메모칸을 덮어쓸지(화이트보드) 아니면 이어 쓸지(노트) 정하는 규칙이며, 대화 메시지엔 이어쓰기 리듀서를 붙인다.
세 가지 쓰기 방식
# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from typing import Annotated
# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from typing_extensions import TypedDict
# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langgraph.graph.message import add_messages
# 관련 데이터와 동작을 하나의 이름 아래 묶는 클래스입니다.
class State(TypedDict):
count: int # 화이트보드: 새 값이 옛 값 지움
logs: Annotated[list, add] # 노트: 리스트에 그냥 이어 붙임
messages: Annotated[list, add_messages] # 노트(대화 전용): 메시지를 똑똑하게 쌓음
count는 그냥 두면 화이트보드처럼 덮어쓰기.
Annotated[..., add]를 붙이면 노트처럼 이어쓰기.
대화 메시지는 add_messages를 쓴다. 메시지를 알아서 잘 쌓아 준다.
예시 폭격
예시 1 (완성예) — 같은 칸인데 규칙만 다르다.
# 화이트보드: {"count": 1} 다음 {"count": 2} → 최종 2 (앞값 지워짐)
# 이 줄은 예제 흐름을 이루는 실제 처리 단계입니다.
count: int
# 노트: ["hi"] 다음 ["bye"] → 최종 ["hi", "bye"] (앞값 보존)
# 모델에게 어떤 역할과 입력을 줄지 프롬프트 틀을 만듭니다.
messages: Annotated[list, add_messages]
예시 2 (부분완성) — 대화칸을 이어쓰기로 만들어 보자.
# 관련 데이터와 동작을 하나의 이름 아래 묶는 클래스입니다.
class State(TypedDict):
messages: Annotated[list, ____] # 빈칸: add_messages — 대화는 쌓여야 함
예시 3 (독립적용) — "검색 기록(search_log)"을 노트처럼 쌓고 싶다. 어떤 표시를 붙일까? (Annotated[list, add] — 일반 목록 이어쓰기.)
미니 시나리오
대화가 자꾸 한 줄로만 남는다? → 메시지칸이 화이트보드(덮어쓰기)라서다. 이어쓰기 리듀서를 붙이자.
점수 하나만 갱신하면 되는 칸이면? → 굳이 노트일 필요 없다. 덮어쓰기가 깔끔.
개념 4 — 갈림길 만들기 (조건부 에지)
망가지는 장면
챗봇에 검색 도구를 붙였다.
그런데 모든 질문에 검색을 돌린다.
"안녕"이라고만 해도 굳이 웹을 뒤진다.
느리고, 돈도 더 든다.
"검색이 필요할 때만 검색칸으로" 가는 갈림길이 없어서다.
일상비유
분식집 주문을 떠올려 보자.
"이거 매운맛 돼요?" 물으면, 점원이 맵게/안 맵게 갈림길에서 고른다.
손님 말(메모)을 보고 길을 정한다.
판도 똑같다. 메모를 보는 판단 함수가 갈림길에서 다음 칸을 고른다.
이 갈림길 화살표가 조건부 에지다.
| 비유 | 코드 | 위험 |
|---|---|---|
| 일반 화살표(외길) | add_edge("A칸", "B칸") |
항상 B로만 감 |
| 갈림길 화살표 | add_conditional_edges("A칸", 판단함수) |
판단함수 없으면 못 갈라짐 |
한 문장 정의 — 조건부 에지는 메모를 보는 판단 함수를 끼워, 그 결과에 따라 갈 칸을 고르는 갈림길 화살표다.
판단 함수 = 갈림길 안내원
판단 함수는 메모를 받아 다음 칸 이름을 돌려준다.
# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from typing import Literal
# 메모를 보고 "검색칸"으로 갈지 "끝"으로 갈지 고름
# 반복해서 쓸 처리 흐름에 이름을 붙인 함수입니다.
def route(state) -> Literal["검색칸", "끝"]:
# 모델에게 어떤 역할과 입력을 줄지 프롬프트 틀을 만듭니다.
last = state["messages"][-1]
# 조건을 확인해서 상황에 맞는 처리 경로를 고릅니다.
if 도구가_필요하면(last):
# 계산하거나 처리한 결과를 이 함수를 부른 쪽으로 돌려줍니다.
return "검색칸"
# 계산하거나 처리한 결과를 이 함수를 부른 쪽으로 돌려줍니다.
return "끝"
graph.add_conditional_edges("챗봇칸", route) # 챗봇칸 뒤에 갈림길
도구 호출 정보가 메시지에 있으면 "검색칸"으로, 없으면 "끝"으로.
이렇게 필요할 때만 검색이 돈다.
출발부터 갈림길
갈림길은 START 바로 뒤에도 놓을 수 있다.
들어온 질문에 따라 첫 칸부터 다르게 시작한다.
# 출발 지점에서 바로 갈림길 — 질문 종류에 따라 첫 칸이 달라짐
# 준비한 함수나 객체를 호출해 예제의 핵심 동작을 실행합니다.
graph.add_conditional_edges(START, route)
예시 폭격
예시 1 (완성예) — 점수로 갈림길을 만든다.
# 반복해서 쓸 처리 흐름에 이름을 붙인 함수입니다.
def route(state) -> Literal["통과칸", "끝"]:
# 조건을 확인해서 상황에 맞는 처리 경로를 고릅니다.
if state["score"] >= 70:
# 계산하거나 처리한 결과를 이 함수를 부른 쪽으로 돌려줍니다.
return "통과칸"
# 계산하거나 처리한 결과를 이 함수를 부른 쪽으로 돌려줍니다.
return "끝"
# 준비한 함수나 객체를 호출해 예제의 핵심 동작을 실행합니다.
graph.add_conditional_edges("채점칸", route)
예시 2 (부분완성) — 빈칸을 채워 갈림길을 매단다.
# 아래 예제는 핵심 흐름만 짧게 보여 줍니다.
graph.add_conditional_edges("챗봇칸", ____) # 빈칸: route — 판단 함수를 끼움
예시 3 (독립적용) — "재고 있으면 결제칸, 없으면 끝"으로 가는 판단 함수를 직접 써 보라. (메모의 재고 값을 보고 "결제칸" 또는 "끝"을 돌려준다.)
미니 시나리오
쉬운 질문에도 검색이 돈다? → 갈림길이 없어서다. 조건부 에지로 "필요할 때만" 검색칸으로 보내자.
길이 무조건 하나뿐이면? → 일반 화살표(add_edge) 하나면 된다. 갈림길은 필요 없다.
개념 5 — 미리 만들어 둔 부품 (Prebuilt)
망가지는 장면
검색 도구를 붙이려고 도구 실행칸을 손수 짰다.
메시지에서 도구 호출 정보를 꺼내고, 도구를 부르고, 결과를 다시 메모에 담고…
코드가 길고, 틀리기 쉽다.
게다가 이건 누구나 똑같이 만드는 흔한 칸이다. 매번 새로 짤 이유가 없다.
일상비유
라면을 끓일 때마다 면을 직접 뽑지 않는다.
이미 만들어진 봉지라면을 산다.
흔히 쓰는 건 누가 미리 만들어 둔 걸 가져다 쓰는 게 빠르고 안전하다.
랭그래프도 자주 쓰는 칸은 미리 만들어 둔다. 가져다 끼우기만 하면 된다.
| 비유 | 코드 | 위험 |
|---|---|---|
| 면 직접 뽑기 | 도구 실행칸을 손수 작성 | 길고 틀리기 쉬움 |
| 봉지라면(미리 만든 부품) | ToolNode(tools=[...]) |
있는 줄 모르면 또 손으로 짬 |
한 문장 정의 — 미리 만든 부품(Prebuilt)은 도구 실행칸과 도구용 갈림길 같은 흔한 조각을 가져다 끼우기만 하면 되도록 준비해 둔 것이다.
두 가지 단골 부품
# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langgraph.prebuilt import ToolNode, tools_condition
# 도구 실행칸 — 손수 짜던 걸 한 줄로
# `tool_node`에 중간 결과를 담아 다음 줄에서 재사용합니다.
tool_node = ToolNode(tools=[검색도구])
# 준비한 함수나 객체를 호출해 예제의 핵심 동작을 실행합니다.
graph.add_node("도구칸", tool_node)
# 도구용 갈림길 — 도구 호출 있으면 도구칸, 없으면 끝
# 준비한 함수나 객체를 호출해 예제의 핵심 동작을 실행합니다.
graph.add_conditional_edges("챗봇칸", tools_condition)
ToolNode는 도구 실행칸을, tools_condition은 "도구 필요하면 도구칸으로" 가는 갈림길을 대신 만들어 준다.
예시 폭격
예시 1 (완성예) — 손수 짠 갈림길을 미리 만든 부품으로 바꾼다.
# 전: 판단 함수를 직접 작성해서 매달았다
# 준비한 함수나 객체를 호출해 예제의 핵심 동작을 실행합니다.
graph.add_conditional_edges("챗봇칸", 손수짠_route)
# 후: 미리 만든 tools_condition으로 한 줄
# 준비한 함수나 객체를 호출해 예제의 핵심 동작을 실행합니다.
graph.add_conditional_edges("챗봇칸", tools_condition)
예시 2 (부분완성) — 빈칸을 채워 도구칸을 미리 만든 부품으로.
tool_node = ____(tools=[검색도구]) # 빈칸: ToolNode
# 준비한 함수나 객체를 호출해 예제의 핵심 동작을 실행합니다.
graph.add_node("도구칸", tool_node)
예시 3 (독립적용) — 손수 짠 도구 실행칸 30줄이 있다. 이걸 한 줄로 줄이려면 뭘 가져다 쓸까? (ToolNode.)
미니 시나리오
도구 칸 코드가 너무 길고 자꾸 틀린다? → 미리 만든 ToolNode로 바꾸면 한 줄이다.
아주 특이한 도구 동작이 필요하다? → 그때만 손수 짜자. 흔한 건 미리 만든 부품.
개념 6 — 중간 결과 흘려보기 (스트리밍)
망가지는 장면
판이 여러 칸을 거쳐 답을 만든다.
그동안 화면은 아무것도 안 뜨고 멈춰 있다.
검색하고, 평가하고, 답 만드는 30초 내내 깜깜이다.
사용자는 "멈췄나?" 하고 창을 닫는다.
일상비유
택배 배송 알림을 떠올려 보자.
"집화 → 이동 중 → 배송 출발 → 도착."
최종 도착만 알려 주면 답답하다.
중간중간 지금 어디인지 흘려 주니 안심된다.
판도 칸을 지날 때마다 중간 상태를 흘려 줄 수 있다. 이게 스트리밍이다.
| 비유 | 코드 | 위험 |
|---|---|---|
| 도착만 알림 | 끝까지 기다렸다 한 번에 출력 | 그동안 깜깜이 |
| 중간 위치 알림 | graph.stream(..., stream_mode="updates") |
모드 잘못 고르면 너무 많이/적게 나옴 |
한 문장 정의 — 스트리밍은 판이 칸을 하나씩 지날 때마다 중간 결과를 흘려보내, 다 끝나기 전에도 진행을 보여 주는 기능이다.
두 가지 흘리기 방식
# values: 칸 지날 때마다 메모 '전체'를 보여줌 (전체 흐름 추적)
# 준비한 흐름에 실제 입력을 넣어 결과를 확인합니다.
graph.stream(inputs, stream_mode="values")
# updates: '바뀐 부분만' 보여줌 (최신 결과만 빠르게)
# 준비한 흐름에 실제 입력을 넣어 결과를 확인합니다.
graph.stream(inputs, stream_mode="updates")
values는 메모 전체를, updates는 바뀐 조각만 흘린다.
예시 폭격
예시 1 (완성예) — 바뀐 부분만 흘려 받아 출력.
# 준비한 흐름에 실제 입력을 넣어 결과를 확인합니다.
events = graph.stream(inputs, stream_mode="updates")
# 목록이나 결과 묶음을 하나씩 꺼내 같은 처리를 반복합니다.
for event in events:
# 목록이나 결과 묶음을 하나씩 꺼내 같은 처리를 반복합니다.
for value in event.values():
# 모델에게 어떤 역할과 입력을 줄지 프롬프트 틀을 만듭니다.
print("Assistant:", value["messages"][-1].content)
예시 2 (부분완성) — 빈칸을 채워 "전체 상태" 흘리기로.
# 준비한 흐름에 실제 입력을 넣어 결과를 확인합니다.
events = graph.stream(inputs, stream_mode="____") # 빈칸: values
예시 3 (독립적용) — "최신 답 한 줄만 빠르게 보고 싶다." 어떤 모드를 고를까? (updates.)
미니 시나리오
긴 작업에 화면이 멈춰 보인다? → 스트리밍으로 중간 결과를 흘리자.
전체 대화 흐름을 다 추적하고 싶다? → values 모드.
개념 7 — 대화를 기억시키기 (상태 저장)
망가지는 장면
챗봇에게 "내 이름은 오해원이야"라고 알려 줬다.
다음 줄에 "내 이름 기억해?" 물으니 "모릅니다" 한다.
판은 실행이 끝나면 메모를 버린다.
매번 빈 메모로 새로 시작하니, 바로 앞 대화도 잊는다.
일상비유
단골 카페와 처음 가는 카페의 차이다.
처음 가는 카페는 매번 "어떻게 드릴까요?" 묻는다(기억 없음).
단골 카페는 손님 카드를 꺼내 "늘 드시던 걸로?" 한다(기억 있음).
판도 메모를 저장해 두면, 다음에 같은 손님이 오면 그 카드를 꺼낸다.
이 저장 담당을 체크포인터, 손님 카드 번호를 thread_id라 한다.
| 비유 | 코드 | 위험 |
|---|---|---|
| 손님 카드 보관함 | compile(checkpointer=MemorySaver()) |
안 켜면 매번 처음 손님 |
| 손님 카드 번호 | {"configurable": {"thread_id": "1"}} |
번호 안 주면 카드 못 찾음 |
한 문장 정의 — 체크포인터와 thread_id를 함께 쓰면, 판이 대화 메모를 저장·복원해 챗봇이 이전 대화를 기억한다.
둘은 한 세트
저장함을 켜고(checkpointer), 손님 번호를 줘야(thread_id) 기억이 작동한다.
# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langgraph.checkpoint.memory import MemorySaver
# `memory`에 중간 결과를 담아 다음 줄에서 재사용합니다.
memory = MemorySaver()
graph = graph_builder.compile(checkpointer=memory) # 저장함 켜기
# 손님 번호 1번으로 대화 — 이 번호의 카드에 기록이 쌓임
# 여러 값을 이름표가 붙은 구조로 묶어 전달합니다.
config = {"configurable": {"thread_id": "1"}}
# 준비한 흐름에 실제 입력을 넣어 결과를 확인합니다.
graph.stream({"messages": [("user", "내 이름은 오해원이야")]}, config)
# 준비한 흐름에 실제 입력을 넣어 결과를 확인합니다.
graph.stream({"messages": [("user", "내 이름 기억해?")]}, config)
# → "네, 오해원님!" (같은 번호라 카드가 이어짐)
번호가 다르면 남남
# 번호 2번은 다른 손님 카드 → 1번 대화를 모름
# 여러 값을 이름표가 붙은 구조로 묶어 전달합니다.
config2 = {"configurable": {"thread_id": "2"}}
# → "이름을 다시 말씀해 주세요" (카드가 비어 있음)
손님 카드 내용은 get_state로 들춰볼 수 있다.
# 아래 예제는 핵심 흐름만 짧게 보여 줍니다.
snapshot = graph.get_state(config) # 1번 카드에 쌓인 대화 전부 조회
예시 폭격
예시 1 (완성예) — 저장함을 켜고 컴파일.
# `memory`에 중간 결과를 담아 다음 줄에서 재사용합니다.
memory = MemorySaver()
# `graph`에 중간 결과를 담아 다음 줄에서 재사용합니다.
graph = graph_builder.compile(checkpointer=memory)
예시 2 (부분완성) — 빈칸을 채워 손님 번호를 만든다.
# 아래 예제는 핵심 흐름만 짧게 보여 줍니다.
config = {"configurable": {"____": "alice"}} # 빈칸: thread_id
예시 3 (독립적용) — Alice와 Bob의 대화를 안 섞이게 하려면? (둘에게 서로 다른 thread_id를 준다.)
미니 시나리오
챗봇이 앞 대화를 잊는다? → 저장함(checkpointer)을 안 켰거나 번호를 안 줬다. 둘 다 챙기자.
한 사람 대화가 다른 사람한테 새어 나간다? → 번호(thread_id)가 같아서다. 사람마다 다른 번호.
개념 8 — 중간에 사람이 끼어들기 (루프 개입)
망가지는 장면
판이 "도구를 호출하겠다"고 정했다.
그런데 그 도구가 위험한 작업(예: 돈 보내기)이다.
판은 사람한테 묻지도 않고 바로 실행해 버린다.
"잠깐, 이건 내가 확인하고 싶었는데" 할 틈이 없다.
일상비유
자동 결제 직전의 최종 확인창을 떠올려 보자.
"정말 결제할까요? [확인] [취소]."
기계가 멋대로 긁기 전에, 사람이 한 번 멈춰 본다.
판도 특정 칸 직전에 멈춰 사람이 들여다보게 할 수 있다.
이 멈춤이 루프 개입이고, 멈추라는 표시가 interrupt_before다.
| 비유 | 코드 | 위험 |
|---|---|---|
| 결제 전 확인창 | compile(interrupt_before=["도구칸"]) |
안 걸면 위험 칸이 바로 실행 |
| 사람이 메모 고치기 | graph.update_state(config, {...}) |
잘못 고치면 흐름 꼬임 |
한 문장 정의 — interrupt_before로 지정한 칸 직전에 판이 멈추고, 사람이 메모를 확인·수정한 뒤 다시 진행시킬 수 있다.
멈추고, 고치고, 잇기
# '도구칸' 들어가기 직전에 멈춤
# `graph`에 중간 결과를 담아 다음 줄에서 재사용합니다.
graph = graph_builder.compile(
# `checkpointer`에 중간 결과를 담아 다음 줄에서 재사용합니다.
checkpointer=memory,
# `interrupt_before`에 중간 결과를 담아 다음 줄에서 재사용합니다.
interrupt_before=["도구칸"],
)
# 준비한 흐름에 실제 입력을 넣어 결과를 확인합니다.
graph.stream({"messages": [("user", "지금 서울 날씨 어때?")]}, config)
# → 도구 호출 정보만 만들고 멈춤 (아직 실행 안 함)
# 지금 멈춰 있으니 메모를 사람이 고칠 수 있음
# `snapshot`에 중간 결과를 담아 다음 줄에서 재사용합니다.
snapshot = graph.get_state(config)
print(snapshot.next) # 다음 칸이 '도구칸'임을 확인
멈춘 동안 메모를 직접 고쳐 도구를 안 부르고 답을 강제로 넣을 수도 있다.
이렇게 사람이 끼어들면, 위험한 칸을 검토하거나 답을 다듬을 수 있다.
예시 폭격
예시 1 (완성예) — 도구칸 직전에 멈추게 설정.
# `graph`에 중간 결과를 담아 다음 줄에서 재사용합니다.
graph = graph_builder.compile(
# `checkpointer`에 중간 결과를 담아 다음 줄에서 재사용합니다.
checkpointer=memory,
# `interrupt_before`에 중간 결과를 담아 다음 줄에서 재사용합니다.
interrupt_before=["도구칸"],
)
예시 2 (부분완성) — 빈칸을 채워 멈출 칸을 지정.
# `graph`에 중간 결과를 담아 다음 줄에서 재사용합니다.
graph = graph_builder.compile(
# `checkpointer`에 중간 결과를 담아 다음 줄에서 재사용합니다.
checkpointer=memory,
____=["결제칸"], # 빈칸: interrupt_before
)
예시 3 (독립적용) — 멈춘 상태에서 다음에 어느 칸이 실행될지 확인하려면? (get_state로 스냅샷을 받아 .next를 본다.)
미니 시나리오
위험한 도구가 확인 없이 실행된다? → interrupt_before로 그 칸 앞에 멈춤을 걸자.
멈춘 뒤 답을 사람이 직접 넣고 싶다? → update_state로 메모를 고친 뒤 잇는다.
개념 9 — 스스로 고치는 RAG (자체교정-RAG)
망가지는 장면
기본 RAG는 찾은 문서를 무조건 믿는다.
검색이 엉뚱한 문서를 물어와도 그대로 안고 답한다.
문서가 질문과 무관하면, 답도 엉터리다.
"찾았으니 됐지"가 아니라 찾은 게 쓸 만한지 따져야 한다.
일상비유
리포트를 쓰는 학생을 떠올려 보자.
도서관에서 책을 뽑았는데, 주제와 안 맞는 책이다.
똑똑한 학생은 그냥 안 쓴다.
검색어를 바꿔 다시 찾거나, 인터넷을 뒤져 보강한다.
자체교정-RAG가 바로 이 똑똑한 학생이다.
| 비유 | 코드 | 위험 |
|---|---|---|
| 뽑은 책을 무조건 씀 | 기본 RAG: 검색 → 답 | 엉뚱한 책이면 답도 틀림 |
| 책이 맞는지 보고 다시 찾음 | 평가칸 + 재검색 갈림길 | 평가 빠지면 또 엉터리 |
한 문장 정의 — 자체교정-RAG는 찾은 문서가 질문과 맞는지 평가하고, 안 맞으면 질문을 고쳐 웹 검색으로 보강한 뒤 답하는 판이다.
흐름 한눈에
판의 칸을 순서대로 보면 이렇다.
- 검색칸 — 질문으로 문서를 찾는다.
- 평가칸 — 찾은 문서가 질문과 맞는지 "예/아니오"로 매긴다.
- 갈림길 — 맞으면 바로 답하기칸으로. 안 맞으면 아래로.
- 질문고치기칸 — 웹 검색에 더 잘 맞게 질문을 다듬는다.
- 웹검색칸 — 다듬은 질문으로 웹을 뒤져 문서를 보탠다.
- 답하기칸 — 확보한 문서로 답을 만든다.
# 평가 결과로 갈림길 — 안 맞으면 질문 고치기로, 맞으면 답하기로
# 반복해서 쓸 처리 흐름에 이름을 붙인 함수입니다.
def decide(state):
if state["web_search"] == "예": # 안 맞는 문서가 있었음
# 계산하거나 처리한 결과를 이 함수를 부른 쪽으로 돌려줍니다.
return "질문고치기칸"
# 계산하거나 처리한 결과를 이 함수를 부른 쪽으로 돌려줍니다.
return "답하기칸"
# 준비한 함수나 객체를 호출해 예제의 핵심 동작을 실행합니다.
workflow.add_conditional_edges("평가칸", decide)
예시 폭격
예시 1 (완성예) — 평가칸이 문서마다 "예/아니오"를 매긴다.
# 반복해서 쓸 처리 흐름에 이름을 붙인 함수입니다.
def grade_documents(state):
# `web_search`에 중간 결과를 담아 다음 줄에서 재사용합니다.
web_search = "아니오"
# `keep`에 중간 결과를 담아 다음 줄에서 재사용합니다.
keep = []
# 목록이나 결과 묶음을 하나씩 꺼내 같은 처리를 반복합니다.
for d in state["documents"]:
# 준비한 흐름에 실제 입력을 넣어 결과를 확인합니다.
score = 평가기.invoke({"question": state["question"], "document": d})
# 조건을 확인해서 상황에 맞는 처리 경로를 고릅니다.
if score == "예":
keep.append(d) # 맞는 문서만 챙김
# 위 조건에 해당하지 않을 때 실행할 대체 경로입니다.
else:
web_search = "예" # 하나라도 안 맞으면 웹 검색 켬
# 계산하거나 처리한 결과를 이 함수를 부른 쪽으로 돌려줍니다.
return {"documents": keep, "web_search": web_search}
예시 2 (부분완성) — 빈칸을 채워 갈림길을 매단다.
# 아래 예제는 핵심 흐름만 짧게 보여 줍니다.
workflow.add_conditional_edges("평가칸", ____) # 빈칸: decide — 평가 결과로 갈라짐
예시 3 (독립적용) — "문서가 다 맞으면 어디로, 하나라도 안 맞으면 어디로" 가는지 위 흐름을 보고 말해 보라. (다 맞으면 답하기칸, 안 맞으면 질문고치기칸 → 웹검색칸.)
미니 시나리오
검색이 자꾸 엉뚱한 문서를 물어온다? → 평가칸을 끼워 거르고, 웹 검색으로 보강하자.
자료가 늘 충분하고 정확하다? → 굳이 평가·재검색까지 안 가도 된다. 기본 RAG로 충분.
개념 10 — 스스로 테스트하는 코드 챗봇 (코드 어시스트)
망가지는 장면
코드 짜 주는 챗봇을 만들었다.
그런데 돌려보지도 않은 코드를 내놓는다.
import가 빠졌거나 오타가 있어도 그대로 준다.
사용자가 붙여 넣으면 그제야 에러가 터진다.
일상비유
요리사가 맛도 안 보고 손님상에 내는 격이다.
좋은 요리사는 내기 전에 한 입 맛본다.
짜면 다시 간을 맞춘다.
코드 챗봇도 직접 돌려 보고, 에러 나면 고쳐서 다시 만든다.
| 비유 | 코드 | 위험 |
|---|---|---|
| 맛 안 보고 냄 | 코드 생성 → 바로 제공 | 안 돌아가는 코드 |
| 맛보고 다시 간 맞춤 | 생성 → 실행 검사 → 재생성 | 무한 재시도 (멈춤 필요) |
한 문장 정의 — 코드 어시스트 챗봇은 코드를 직접 실행해 테스트하고, 에러가 나면 고쳐서 다시 만드는 루프를 도는 판이다.
생성 → 검사 → (틀리면) 반영 → 재생성
판의 칸은 셋이다.
- 생성칸 — 질문을 보고 코드를 만든다.
- 검사칸 — 만든 코드를 직접 돌려 본다. (
import먼저, 그다음 전체.) - 반영칸 — 에러가 나면 그 에러를 메모에 적고 생성칸으로 되돌린다.
# 직접 실행해 보고 에러 잡기
# 반복해서 쓸 처리 흐름에 이름을 붙인 함수입니다.
def code_check(state):
# `code`에 중간 결과를 담아 다음 줄에서 재사용합니다.
code = state["generation"]
# 실패할 수 있는 처리를 먼저 시도합니다.
try:
exec(code.imports) # import만 먼저 돌려 봄
exec(code.imports + "\n" + code.code) # 전체 돌려 봄
# 실패가 발생했을 때 사용자에게 보일 대체 처리를 합니다.
except Exception as e:
return {"error": "yes"} # 터지면 '에러 있음' 표시
return {"error": "no"} # 멀쩡하면 통과
무한 반복 막기
에러가 계속 나면 영영 돌 수 있다.
그래서 시도 횟수를 세고, 정한 횟수(예: 3회)를 넘으면 멈춘다.
# 반복해서 쓸 처리 흐름에 이름을 붙인 함수입니다.
def decide_to_finish(state):
# 에러 없거나, 3번 시도했으면 종료
# 조건을 확인해서 상황에 맞는 처리 경로를 고릅니다.
if state["error"] == "no" or state["iterations"] >= 3:
# 계산하거나 처리한 결과를 이 함수를 부른 쪽으로 돌려줍니다.
return "끝"
return "반영칸" # 아니면 고쳐서 다시
예시 폭격
예시 1 (완성예) — 통과/재시도 갈림길.
# 반복해서 쓸 처리 흐름에 이름을 붙인 함수입니다.
def decide_to_finish(state):
# 조건을 확인해서 상황에 맞는 처리 경로를 고릅니다.
if state["error"] == "no" or state["iterations"] >= 3:
# 계산하거나 처리한 결과를 이 함수를 부른 쪽으로 돌려줍니다.
return "끝"
# 계산하거나 처리한 결과를 이 함수를 부른 쪽으로 돌려줍니다.
return "반영칸"
예시 2 (부분완성) — 빈칸을 채워 종료 조건을 완성.
if state["error"] == "no" or state["iterations"] >= ____: # 빈칸: 3 — 최대 시도
# 계산하거나 처리한 결과를 이 함수를 부른 쪽으로 돌려줍니다.
return "끝"
예시 3 (독립적용) — "import는 되는데 본문 실행에서 터지는" 코드는 검사칸에서 어떻게 잡힐까? (두 번째 exec(전체 실행)에서 예외가 나 error: yes로 표시된다.)
미니 시나리오
챗봇이 안 돌아가는 코드를 준다? → 검사칸을 끼워 직접 실행시키고, 에러 나면 재생성 루프로 보내자.
재시도가 끝없이 돈다? → 시도 횟수 상한(예: 3회)을 둬서 멈추게 하자.
정리
이 장은 한 줄로 흐르던 RAG를 판(그래프)으로 바꾸는 이야기였다.
판은 셋으로 만든다 — 일하는 칸(노드), 잇는 화살표(에지), 돌려 쓰는 메모(상태).
갈림길은 조건부 에지가, 기억은 상태 저장이, 사람 끼어들기는 루프 개입이 맡는다.
이 부품으로 스스로 고치는 RAG와 스스로 테스트하는 코드 챗봇을 조립한다.
한 걸음 더 ▸ (지금 몰라도 됨)
"판을 언제까지 키워야 하나? 칸을 몇 개까지?" — 이런 설계 판단은 더 깊은 주제다.
지금은 "한 줄 → 판으로 바뀐다, 판은 칸·화살표·메모 셋이다" 한 가지만 들고 가면 된다.
메모를 메모리 말고 데이터베이스에 저장하는 법, 사람 개입을 더 정교하게 거는 법도 더 깊은 이야기다. 기본 판을 먼저 익히면 거기에 얹는 것뿐이다.
더 해보기
검증된 공식 자료와 책 실습 노트북(생존 확인 2026-05-21).
- 랭그래프 공식 문서 — langchain-ai.github.io/langgraph (전체 길잡이)
- 빠른 시작 튜토리얼 — tutorials/introduction (첫 판 만들기)
- 상태 저장(체크포인터) 개념 — concepts/persistence (대화 기억)
- 사람 개입 개념 — concepts/human_in_the_loop (중간에 끼어들기)
- 미리 만든 부품 레퍼런스 — reference/prebuilt (ToolNode·tools_condition)
- 책 공식 실습 노트북(코랩) — Ch06. LangGraph 폴더 (랭그래프 기초·자체교정 RAG·코드 어시스트 챗봇)
모델·가격은 수시로 바뀐다. 책의
gpt-4o-mini표기는 2025년 기준 예시이니, 실제로 쓸 땐 공식 모델 목록을 확인하라: OpenAI. (검증 2026-05-21)
연습문제
- 설명.
레고 조립도로 만드는 똑똑한 RAG의 핵심을 처음 듣는 사람에게 한 문장으로 설명하라. - 구분. 두 개념(
체인,그래프)을 실제 예시 하나로 구분하라. - 적용. 내 프로젝트나 학습 노트에서 이 장의 개념을 적용해 작게 개선할 지점을 하나 고르라.
부록 A. 쉬운 용어 사전
| 용어 | 아주 쉬운 뜻 | 이 장에서 나온 위치 |
|---|---|---|
| 체인 | 부품을 정해진 순서로 이어 입력이 차례대로 흐르게 만든 구조. | 부록 B와 본문 예시 |
| 그래프 | 갈림길, 반복, 되돌아가기 같은 복잡한 흐름을 표현하는 구조. | 부록 B와 본문 예시 |
| 무상태 | 이전 대화나 중간 결과를 따로 기억하지 않는 방식. | 부록 B와 본문 예시 |
| 상태 저장 | 이전 대화나 중간 결과를 저장해 다음 단계에서 다시 쓰는 방식. | 부록 B와 본문 예시 |
부록 B. 헷갈리는 개념 비교표
| A | B | 구분 포인트 |
|---|---|---|
| 체인 | 그래프 | 체인은 한 줄 흐름에 가깝고, 그래프는 갈림길과 반복을 표현하기 좋다. |
| 무상태 | 상태 저장 | 무상태는 기억하지 않고, 상태 저장은 이전 대화나 중간 결과를 남긴다. |
부록 C. 더 읽을 자료
- 이 장의
더 해보기섹션 — 이미 모아 둔 공식 문서나 실습 링크가 있으면 여기서 먼저 확인한다. - 같은 책의
0장 한눈에 보기— 용어가 막히면 0장의 용어집과 개념 척추로 돌아간다. - 원본 딥다이브판 같은 장 — 입문판을 읽고 큰 흐름이 잡힌 뒤 세부 논리를 더 깊게 확인한다.
- 이 장의
flashcards.json— 읽은 직후 질문만 보고 답을 떠올리는 회상 연습에 쓴다.
부록 D. 연습문제 풀이
- 설명 예시.
레고 조립도로 만드는 똑똑한 RAG는 RAG에서 자료를 더 잘 찾고, 근거를 더 안전하게 붙이고, 답변 흐름을 더 다루기 쉽게 만드는 방법을 보는 장이다. 중요한 것은 용어를 외우는 것이 아니라, 이 개념이 어떤 입력·부품·결정에 영향을 주는지 말로 풀어 보는 것이다. - 구분 예시. 두 개념(
체인,그래프)의 차이는 이렇게 잡으면 된다. 체인은 한 줄 흐름에 가깝고, 그래프는 갈림길과 반복을 표현하기 좋다. 실제 사례를 볼 때는 목적, 입력, 실패했을 때의 증상을 따로 적어 보면 헷갈리지 않는다. - 적용 예시. 가장 작은 개선부터 고른다. 예를 들어 이름을 더 분명히 하거나, 평가 기준을 한 줄 추가하거나, 직접 알 필요 없는 내부 정보를 감추는 식으로 시작한다. 한 번에 크게 갈아엎는 것보다 작은 변경 하나를 확인하며 진행하는 쪽이 입문 단계에 맞다.
다음 장 예고
다음 장에서는 도구를 스스로 골라 쓰는 더 똑똑한 에이전트로 한 걸음 더 간다.
지금은 6장의 "판 = 칸·화살표·메모" 한 줄만 머리에 있으면 충분하다.
클릭하거나 Space를 눌러 뒤집기